From aba6999f97c452563d7cdfecf01a931154dd4e86 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 14 Jul 2023 11:38:49 -0500 Subject: [PATCH 1/7] Allow `__index__` only for integral dtypes on Scalars --- .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 39a4451c86b57132f3a94ec56a1f48d11006d398 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 16 Jul 2023 16:11:58 -0500 Subject: [PATCH 2/7] Add `matrix.power` to compute e.g. `A @ A @ A` --- graphblas/core/automethods.py | 5 +++ graphblas/core/infix.py | 1 + graphblas/core/matrix.py | 69 +++++++++++++++++++++++++++++++++- graphblas/tests/test_matrix.py | 31 +++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) 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..5fb820cd4 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,49 @@ 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 + result = square = square_expr = None + n, bit = divmod(n, 2) + while True: + if bit != 0: + if square_expr is not None: + if n == 0 and result is None: + updater << square_expr + return + if square is None: + square = square_expr.new(name="Squares", **opts) + else: + square(**opts) << square_expr + square_expr = None + if result is None: + if square is None: + result = A + else: + result = square.dup(name="Power", **opts) + elif n == 0: + updater << op(result @ square) + return + elif result is A: + result = op(result @ square).new(name="Power", **opts) + else: + result(**opts) << op(result @ square) + n, bit = divmod(n, 2) + if square_expr is not None: + if square is None: + square = square_expr.new(name="Squares", **opts) + else: + square << square_expr + if square is None: + square_expr = op(A @ A) + else: + square_expr = op(square @ square) + + class Matrix(BaseType): """Create a new GraphBLAS Sparse Matrix. @@ -155,8 +199,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 +2732,26 @@ def reposition(self, row_offset, column_offset, *, nrows=None, ncols=None): dtype=self.dtype, ) + def power(self, n, op=semiring.plus_times): + 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 +3420,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 +3521,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 +3683,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) From cd609cd89b5de2302f3e51e27dc14a6517722d20 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 17 Jul 2023 23:43:09 -0500 Subject: [PATCH 3/7] Add Matrix.power docstring --- graphblas/core/matrix.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 5fb820cd4..0971ae330 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -97,7 +97,9 @@ def _power(updater, A, n, op): if n == 1: updater << A return - # Use repeated squaring; compute A^2, A^4, A^8, etc., and combine terms + # 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: @@ -2733,6 +2735,40 @@ def reposition(self, row_offset, column_offset, *, nrows=None, ncols=None): ) 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}") From 8cc1cdc214091b8faf43ed66072f0763e7160dc4 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 19 Jul 2023 13:37:08 -0500 Subject: [PATCH 4/7] Bump flake8-bugbear --- .pre-commit-config.yaml | 2 +- scripts/check_versions.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8d767f05..9ea28291f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 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 1d7c2ae7abfdc4afb6a1a0d87aeb9cd85206e063 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 26 Jul 2023 12:07:56 -0500 Subject: [PATCH 5/7] Bump ruff --- .pre-commit-config.yaml | 4 ++-- graphblas/tests/test_vector.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ea28291f..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] @@ -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/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 From 941d95fc5b738f863841d2a33cd0d6b24c51a393 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 26 Jul 2023 15:07:14 -0500 Subject: [PATCH 6/7] Code comments for `power` implementation --- graphblas/core/matrix.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 0971ae330..d820ca424 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -104,36 +104,53 @@ def _power(updater, A, n, op): 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) From 60a1d44f0ebd849337d160b2560ff088b3949813 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 26 Jul 2023 15:49:45 -0500 Subject: [PATCH 7/7] sheesh! coveralls.io is not happy --- .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 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 }}